#!/usr/bin/python

from __future__ import absolute_import, print_function

from optparse import OptionParser, make_option
import dbus
import dbus.service
import dbus.mainloop.glib
import bluezutils
import time
import os
import logging
import signal
import subprocess
from gi.repository import GLib

AGENT_INTERFACE = 'org.bluez.Agent1'

gadapter = None
gdiscovering = False
gdevices = {}
glisting_mode = False
glisting_devs = {}

logging.basicConfig(filename='/var/log/bluetooth-agent.log', level=logging.DEBUG, format='%(asctime)s %(message)s')

def bool2str(val, valiftrue, valiffalse):
  if val:
    return valiftrue
  else:
    return valiffalse

def logging_status(msg):
  with open("/var/run/bt_status", "w") as file:
    file.write(msg + "\n")

def connect_device(path, address, properties, forceConnect, filter):
  global gdiscovering
  global gadapter

  devName = ""
  trusted = False
  connected = False

  #
  if filter is None:
    logging.info("skipping {}. No filter.".format(address))
    return

  # avoid devices without interesting information
  if "Trusted" not in properties:
    logging.info("skipping {}. No Trusted property.".format(address))
    return
  if "Connected" not in properties:
    logging.info("skipping {}. No Connected property.".format(address))
    return

  trusted   = prop2str(properties["Trusted"])
  paired    = prop2str(properties["Paired"])
  devName = getDevName(properties)
  shortDevName = getShortDevName(properties)
  connected = prop2str(properties["Connected"])

  # skip non input devices
  if "Icon" not in properties:
    logging.info("Skipping device {} (no type)".format(getDevName(properties)));
    return

  logging.info("filter={}, Icon={}, Address={}".format(filter, prop2str(properties["Icon"]), prop2str(properties["Address"])))

  if not (filter is not None and (filter == prop2str(properties["Address"]) or (filter == "input" and prop2str(properties["Icon"]).startswith("input")) )):
    logging.info("Skipping device {} (not {})".format(getDevName(properties), filter));
    return

  logging.info("event for " + devName + "(paired=" + bool2str(paired, "paired", "not paired") + ", trusted=" + bool2str(trusted, "trusted", "untrusted") + ", connected=" + bool2str(connected, "connected", "disconnected") + ")")

  # skipping connected devices
  if paired and trusted and connected:
    logging.info("Skipping already connected device {}".format(getDevName(properties)));
    return
  
  if not paired:
    if connected == False and gdiscovering:
      doPairing(address, devName, shortDevName)
    #return

  # now it is paired
  if not trusted and (gdiscovering or forceConnect):
    logging.info("Trusting (" + devName + ")")
    logging_status("Trusting " + shortDevName + "...")
    bluezProps = dbus.Interface(bus.get_object("org.bluez", path), "org.freedesktop.DBus.Properties")
    bluezProps.Set("org.bluez.Device1", "Trusted", True)

  # now it is paired and trusted
  # Connect if Trusted and paired
  if not connected or forceConnect:
    doConnect(address, devName, shortDevName)

def doPairing(address, devName, shortDevName):
  logging.info("Pairing... (" + devName + ")")
  logging_status("Pairing " + shortDevName + "...")
  device = bluezutils.find_device(address)
  try:
    device.Pair()
  except Exception as e:
    logging.info("Pairing failed (" + devName + ")")
    logging_status("Pairing failed (" + shortDevName + ")")

def doConnect(address, devName, shortDevName):
  global gadapter
  global gdiscovering
  
  try:
    # discovery stopped during connection to help some devices
    if gdiscovering:
      logging.info("Stop discovery")
      gadapter.StopDiscovery()

    device = bluezutils.find_device(address)
    ntry=5
    while ntry > 0:
      ntry = ntry -1
      try:
        logging.info("Connecting... (" + devName + ")")
        logging_status("Connecting " + shortDevName + "...")
        device.Connect()
        logging.info("Connected successfully (" + devName + ")")
        logging_status("Connected successfully (" + shortDevName + ")")

        if gdiscovering:
          logging.info("Start discovery")
          gadapter.StartDiscovery()
          return
      except dbus.exceptions.DBusException as err:
        logging.info("dbus: " + err.get_dbus_message())
        time.sleep(1)
      except Exception as err:
        logging.info("Connection failed (" + devName + ")")
        time.sleep(1)
    
    logging.info("Connection failed. Give up. (" + devName + ")")
    logging_status("Connection failed. Give up. (" + shortDevName + ")")
    if gdiscovering:
      logging.info("Start discovery")
      gadapter.StartDiscovery()
  except Exception as e:
    if gdiscovering:
      logging.info("Start discovery")
      gadapter.StartDiscovery()
    # don't raise, while startdiscovery doesn't like it
    #raise e

def getDevName(properties):
  #devName = properties["Name"] + " (" + properties["Address"] + ", " + properties["Icon"] + ")"
  #devStatus = "Trusted=" + str(properties["Trusted"]) + ", paired=" + str(properties["Paired"]) + ", connected=" + str(properties["Connected"]), ", blocked=" + str(properties["Blocked"])
  #devTech = "legacyPairing: " + str(properties["LegacyPairing"]) # + ", RSSI: " + properties["RSSI"]

  if "Name" in properties and "Address" in properties and "Icon" in properties:
    return prop2str(properties["Name"]) + " (" + prop2str(properties["Address"]) + ", " + prop2str(properties["Icon"]) + ")"

  if "Name" in properties and "Address" in properties:
    return prop2str(properties["Name"]) + " (" + prop2str(properties["Address"]) + ")"

  if "Name" in properties and "Icon" in properties:
    return prop2str(properties["Name"]) + " (" + prop2str(properties["Icon"]) + ")"

  if "Name" in properties:
    return prop2str(properties["Name"])

  if "Address" in properties and "Icon" in properties:
    return prop2str(properties["Address"]) + " (" + prop2str(properties["Icon"]) + ")"

  if "Address" in properties:
    return prop2str(properties["Address"])

  if "Icon" in properties:
    return prop2str(properties["Icon"])

  return "unknown"

def prop2str(p):
  newp = p
  if type(newp) is dbus.String:
    newp = newp.encode('ascii', 'replace')
  if type(newp) is bytes:
    newp = newp.decode('UTF-8')
  return newp

def getShortDevName(properties):
  if "Name" in properties:
    return prop2str(properties["Name"])

  if "Address" in properties:
    return prop2str(properties["Address"])

  if "Icon" in properties:
    return prop2str(properties["Icon"])

  return "unknown"

def getDevAddressNameType(properties):
  if "Address" not in properties:
    return None, None, None

  vaddr = prop2str(properties["Address"])
  vname = None
  if "Name" in properties:
    vname = prop2str(properties["Name"])
  vtype = None
  if "Icon" in properties:
    vtype = prop2str(properties["Icon"])

  return vaddr, vname, vtype

def getBluetoothWantedAddr():
  addrDevice = None
  if os.path.isfile("/var/run/bt_device"):
    with open("/var/run/bt_device", "r") as file:
      addrDevice = file.read().strip()
      if addrDevice == "":
        addrDevice = None
      logging.info("bt_dev: {}".format(addrDevice))
  return addrDevice

def interfaces_added(path, interfaces):
  global gdevices
  global glisting_mode

  if "org.bluez.Device1" not in interfaces:
    return
  if not interfaces["org.bluez.Device1"]:
    return

  properties = interfaces["org.bluez.Device1"]

  if path in gdevices:
    gdevices[path] = merge2dicts(gdevices[path], properties)
  else:
    gdevices[path] = properties

  logging.info("Interface added: {}".format(propertiesToStr(properties)))

  if glisting_mode:
    listing_dev_event(path, gdevices[path], True)

  if "Address" in gdevices[path]:
    connect_device(path, prop2str(properties["Address"]), gdevices[path], False, getBluetoothWantedAddr())
  else:
    logging.info("No address. skip.")

def interfaces_removed(path, interfaces):
  global gdevices

  # Remove from gdevices only if the Device1 interface itself is removed
  if "org.bluez.Device1" in interfaces and path in gdevices:
    listing_dev_event(path, gdevices[path], False)
    del gdevices[path]

def propertiesToStr(properties):
  str = ""
  for p in properties:
    if p not in ["ServiceData", "RSSI", "UUIDs", "Adapter", "AddressType", "Alias", "Bonded", "ServicesResolved"]:
      if str != "":
        str = str + ", "
      str = str + "{}={}".format(p, properties[p])
  return str

def properties_changed(interface, changed, invalidated, path):
  global gdevices
  global glisting_mode

  if interface != "org.bluez.Device1":
    return

  if path in gdevices:
    gdevices[path] = merge2dicts(gdevices[path], changed)
  else:
    gdevices[path] = changed

  if glisting_mode:
    listing_dev_event(path, gdevices[path], True)

  propstr = propertiesToStr(changed)
  if propstr != "":
    logging.info("Properties changed: {}".format(propstr))

  if "Paired" in changed and changed["Paired"] == True:
    # ok, do as in simple-agent, trust and connect
    connect_device(path, gdevices[path]["Address"], gdevices[path], True, getBluetoothWantedAddr())
    refresh_audio_on_connect(gdevices[path])
    return
  
  # ok, it is now connected, what else ?
  if "Connected" in changed and changed["Connected"] == True:
    refresh_audio_on_connect(gdevices[path])
    return

  if "Connected" in changed and changed["Connected"] == False:
    logging.info("Skipping (property Connected changed to False)");
    refresh_audio_on_disconnect()
    return

  if "Address" in gdevices[path]:
    connect_device(path, gdevices[path]["Address"], gdevices[path], False, getBluetoothWantedAddr())

  refresh_audio_on_connect(gdevices[path])

def merge2dicts(d1, d2):
  res = d1.copy()
  res.update(d2)
  return res

def listing_dev_event(path, properties, adding):
  global glisting_devs

  # device already here
  if path in glisting_devs and adding:
    return

  # device already removed
  if path not in glisting_devs and not adding:
    return

  # update list
  if adding:
    glisting_devs[path] = True
  else:
      del glisting_devs[path]

  devstatus = "added"
  if not adding:
    devstatus = "removed"
  with open("/var/run/bt_listing", "a") as file:
    devaddress, devname, devtype = getDevAddressNameType(properties)
    file.write("<device id=\"{}\" name=\"{}\" status=\"{}\" type=\"{}\" />\n".format(devaddress, devname, devstatus, icon2basicname(devtype)));

def icon2basicname(str):
  if str is None:
    return "unknown"
  if str == "input-gaming":
    return "joystick"
  if str.startswith("audio-"):
    return "audio"
  return str

def user_signal_start_discovery(signum, frame):
  global gdiscovering
  global gadapter
  global glisting_mode
  global glisting_devs
  global gdevices

  if os.path.isfile("/var/run/bt_listing"):
    glisting_mode = True
    logging.info("listing mode enabled");
    glisting_devs = {}
    # initial listing with existing devices
    for path in gdevices:
      listing_dev_event(path, gdevices[path], True)

  try:
    if gdiscovering == False:
      gdiscovering = True
      logging.info("Start discovery (signal)")
      gadapter.StartDiscovery()
  except:
    pass

def user_signal_stop_discovery(signum, frame):
  global gdiscovering
  global gadapter
  global glisting_mode

  if glisting_mode:
    logging.info("listing mode disabled");
  glisting_mode = False

  try:
    if gdiscovering:
      gdiscovering = False
      logging.info("Stop discovery (signal)")
      gadapter.StopDiscovery()
  except:
    pass

def refresh_audio_on_connect(properties):
  # Skip if the device is not an audio device
  if "Icon" not in properties or not prop2str(properties["Icon"]).startswith("audio-"):
    return

  logging.info("Bluetooth audio device connected. Waiting for sink to appear...")

  # Wait up to 10 seconds for a 'bluez_' to appear
  for i in range(100):
    sinks = subprocess.check_output(["pactl", "list", "short", "sinks"], encoding='utf-8')
    if "bluez_" in sinks:
      current_output = subprocess.check_output(["batocera-audio", "get"], encoding='utf-8').strip()
      subprocess.run(["batocera-audio", "set", current_output])
      logging.info("Bluetooth audio sink detected: proceeding with audio refresh.")
      return
    time.sleep(0.1)

  logging.warning("Bluetooth sink not detected within timeout.")

def refresh_audio_on_disconnect():
  current_output = subprocess.check_output(["batocera-audio", "get"], encoding='utf-8').strip()
  subprocess.run(["batocera-audio", "set", current_output])
  logging.info("Bluetooth audio disconnected. Refreshing audio output.")

class Agent(dbus.service.Object):
  exit_on_release = True

  def set_exit_on_release(self, exit_on_release):
    self.exit_on_release = exit_on_release

  @dbus.service.method(AGENT_INTERFACE, in_signature="", out_signature="")
  def Release(self):
    logging.info("agent: Release")
    if self.exit_on_release:
      mainloop.quit()
  
  @dbus.service.method(AGENT_INTERFACE, in_signature="os", out_signature="")
  def AuthorizeService(self, device, uuid):
    logging.info("agent: AuthorizeService")
    return
  
  @dbus.service.method(AGENT_INTERFACE, in_signature="o", out_signature="s")
  def RequestPinCode(self, device):
    logging.info("RequestPinCode (%s)" % (device))
    return "0000"

  @dbus.service.method(AGENT_INTERFACE, in_signature="o", out_signature="u")
  def RequestPasskey(self, device):
    logging.info("RequestPasskey (%s)" % (device))
    return 0
  
  @dbus.service.method(AGENT_INTERFACE, in_signature="ouq", out_signature="")
  def DisplayPasskey(self, device, passkey, entered):
    logging.info("agent: DisplayPasskey (%s, %06u entered %u)" % (device, passkey, entered))
  
  @dbus.service.method(AGENT_INTERFACE, in_signature="os", out_signature="")
  def DisplayPinCode(self, device, pincode):
    logging.info("agent: DisplayPinCode (%s, %s)" % (device, pincode))
  
  @dbus.service.method(AGENT_INTERFACE, in_signature="ou", out_signature="")
  def RequestConfirmation(self, device, passkey):
    logging.info("agent: RequestConfirmation")
    return
  
  @dbus.service.method(AGENT_INTERFACE, in_signature="o", out_signature="")
  def RequestAuthorization(self, device):
    logging.info("agent: RequestAuthorization")
    return
  
  @dbus.service.method(AGENT_INTERFACE, in_signature="", out_signature="")
  def Cancel(self):
    logging.info("agent: Cancel")

def do_main_loop(dev_id):
  global gadapter

  # adapter
  try:
    adapter = bluezutils.find_adapter(dev_id)
  except:
    # try to find any adapter
    adapter = bluezutils.find_adapter(None)
  logging.info("adapter found")

  gadapter = adapter
  adapters = {}

  om = dbus.Interface(bus.get_object("org.bluez", "/"), "org.freedesktop.DBus.ObjectManager")
  objects = om.GetManagedObjects()
  for path, interfaces in objects.items():
    if "org.bluez.Device1" in interfaces:
      gdevices[path] = interfaces["org.bluez.Device1"]
    if "org.bluez.Adapter1" in interfaces:
      adapters[path] = interfaces["org.bluez.Adapter1"]
  
  adapter_props = adapters[adapter.object_path]
  logging.info(adapter_props["Name"] + "(" + adapter_props["Address"] + "), powered=" + str(adapter_props["Powered"]))

  # power on adapter if needed
  if adapter_props["Powered"] == 0:
    try:
      logging.info("powering on adapter ("+ adapter_props["Address"] +")")
      adapterSetter = dbus.Interface(bus.get_object("org.bluez", adapter.object_path), "org.freedesktop.DBus.Properties")
      adapterSetter.Set("org.bluez.Adapter1", "Powered", True)
    except:
      pass # hum, not nice

  gdiscovering = False

  # events
  # use events while i manage to stop discovery only from the process having started it
  signal.signal(signal.SIGUSR1, user_signal_start_discovery)
  signal.signal(signal.SIGUSR2, user_signal_stop_discovery)
  logging.info("signals set")
  
  mainloop = GLib.MainLoop()
  mainloop.run()

if __name__ == '__main__':
  # options
  option_list = [ make_option("-i", "--device", action="store", type="string", dest="dev_id") ]
  parser = OptionParser(option_list=option_list)
  (options, args) = parser.parse_args()

  # register dbus
  dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
  bus = dbus.SystemBus()

  bus.add_signal_receiver(interfaces_added,   dbus_interface = "org.freedesktop.DBus.ObjectManager", signal_name = "InterfacesAdded")
  bus.add_signal_receiver(interfaces_removed, dbus_interface = "org.freedesktop.DBus.ObjectManager", signal_name = "InterfacesRemoved")
  bus.add_signal_receiver(properties_changed, dbus_interface = "org.freedesktop.DBus.Properties",    signal_name = "PropertiesChanged", arg0 = "org.bluez.Device1", path_keyword = "path")

  # register the agent
  agentpath = "/batocera/agent"
  obj = bus.get_object("org.bluez", "/org/bluez")
  manager = dbus.Interface(obj, "org.bluez.AgentManager1")
  manager.RegisterAgent(agentpath, "NoInputNoOutput")
  manager.RequestDefaultAgent(agentpath)
  agent = Agent(bus, agentpath)
  logging.info("agent registered")

  # run the agent, allows some tries while hardware can take time to initiate
  time.sleep(5)
  try:
    do_main_loop(options.dev_id)
  except Exception as e:
    logging.error("agent fails")
    logging.error(e, exc_info=True)
    raise
  logging.error("agent gave up")
